Skip to content

fix(codegen): reuse SignalListing.Range in SignalListing subclasses by shape, and fail loudly on a miss - #1891

Merged
bokelley merged 4 commits into
nextfrom
fix/codegen-range-dedup-open-schema
Nov 10, 2026
Merged

bokelley merged 4 commits into
nextfrom
fix/codegen-range-dedup-open-schema

Conversation

@bokelley

@bokelley bokelley commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Gets the SDK ready for the rc.0 regeneration against adcontextprotocol/adcp#8866, which opens the accidentally closed 3.3 schemas.

Problem

fix_signal_listing_range_subclasses (in scripts/post_generate_fixes.py) exists because a class that extends core/signal-listing.json redeclares range. The generator gives that redeclaration its own Range class. It's identical to SignalListing.Range but a different type, so the subclass's range no longer has its parent's type, and a SignalListing.Range doesn't validate there.

The pass matched the generated class text of two named files, extra='forbid' included. That text had already drifted on next: the generator emits StrictFloat, and the descriptions changed. So the duplicate in core/wholesale_feed_event.py (class Signal(SignalListing)) was never deleted, and the pass still reported success.

The post-generation manifest didn't catch it either:

  • the pass's import rewrite still matched, so the pass counted as firing;
  • remove_imports_shadowed_by_a_local_class then removed that now-shadowed import, so that pass counted as firing too;
  • net effect: nothing changed.

#8866 opens core/signal-listing.json#/properties/range (extra='allow'), which would have kept the text match missing.

Change

The pass no longer names files or matches text:

  • Scan: it checks every generated module for a class that subclasses SignalListing and a top-level Range.

  • Compare: each such Range is checked against SignalListing.Range by what it validates: bases, model_config, field types, Field constraints and defaults. Descriptions are ignored.

  • On a match: it deletes the copy and adds Range to the module's existing from …signal_listing import SignalListing.

  • It raises RuntimeError when:

    • such a copy validates differently (for example, one schema opens and the other doesn't);
    • the module imports SignalListing in a form Range can't be added to;
    • a duplicate survives.

    A silent miss is now a regeneration failure.

  • Schemas that don't subclass are left alone. A module that declares its own Range without subclassing SignalListing keeps it, as every schema keeps its own classes. signals/get_signals_response.py inlined its signal in 3.3 and has no parent type to match, so it's unchanged, along with adcp.types.domains.signals.Range and SignalCoverageRange.

  • Manifest: remove_imports_shadowed_by_a_local_class is now a recorded no-op, with its reason. Its only target was the shadow the broken pass created. It stays as a guard.

Generated diff (3.3.0-beta.5)

  • core/wholesale_feed_event.py: the duplicate Range class is removed, and from .signal_listing import Range, SignalListing.
    • wholesale_feed_event.Signal.range now has the same type as SignalListing.range.
    • wholesale_feed_event.Range is still importable, and is SignalListing.Range.
    • The shape is unchanged: closed, StrictFloat min/max.
  • docs/shared-type-names.md: Range has one variant fewer. SCHEMA_DELTAS.md is the regenerated delta.
  • No public name is added, removed or repointed, so the public API snapshot is unchanged. The core domain root never bound Range, because several core schemas declare it.

Verification

  • generate_types.py --check: up to date. Without the manifest update, the manifest grading fails on the shadow pass.

  • Against #8866's schema bundle (CI artifact schema-pr-bundle-9c1c52de…, #8866's current head, SHA-256 verified): I extracted it into a scratch worktree's cache, ran fix_schema_refs.py, then generate_types.py.

    • The pass reports "Reused SignalListing.Range in 1 subclass file(s)", and generation passes, including manifest grading.
    • wholesale_feed_event.Range is SignalListing.Range, and both are open (extra='allow'). get_signals_response keeps its own Range, which is also open.
  • Tests: new tests/test_signal_listing_range_reuse.py, 19 tests. It covers:

    • the closed beta.5 shape, the open #8866 shape and the no-model_config shape, on subclass modules in two domains;
    • description-only differences;
    • four divergence kinds per module (each raises, and the file is left byte-identical);
    • an inline module with its own Range (untouched, even when it diverges);
    • idempotency, a subclass with no Range, and a missing listing Range;
    • a parenthesized import, and a module-qualified base (raises);
    • a guard on the real generated tree.

    The pre-fix pass, run on the beta.5 and #8866 fixtures, left class Range in place without raising and reported success.

  • Local: the full suite passes (21,962 passed, 2,978 skipped), including test_export_surface_is_derived.py and test_public_api.py. make typecheck passes.

Not in scope

Two other passes hard-code extra='forbid', and neither reads it from the source:

  • fix_verify_brand_claim_models (the JWS envelope; core/response-payload-jws-envelope.json stays closed in #8866, so it's consistent today);
  • restore_trusted_match_compatibility_aliases (the deprecated 3.1.8 TmpxMacro compat model, which has no 3.3 source).

🤖 Generated with Claude Code

bokelley and others added 2 commits November 9, 2026 21:36
…n a copy survives

fix_signal_listing_range_subclasses matched the generated Range text, extra='forbid'
and all. The generator moved to StrictFloat and new descriptions, so the match
stopped deleting the duplicate classes in signals/get_signals_response.py and
core/wholesale_feed_event.py, and still printed success. Its import rewrite
still fired, and remove_imports_shadowed_by_a_local_class then undid it, so the
post-generation manifest saw both passes firing. adcp#8866 opens
core/signal-listing.json#/properties/range, which would have kept it missing.

The pass now finds each copy by name and compares it with SignalListing.Range
by what it validates (bases, model_config, field types, constraints, defaults;
not descriptions). On a match it binds Range to the shared class by assignment,
so adcp.types.domains.signals.Range stays exported. It raises when a copy
validates differently or a Range class survives.

Regenerated on 3.3.0-beta.5: SignalListing subclasses now share
SignalListing.Range, which also removes the parent/override type mismatch on
`range`. Checked against adcp#8866's schema bundle (open range), where the
pass applies to both modules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the public API snapshot

SignalCoverageRange and adcp.types.domains.signals.Range now resolve to the
shared core.signal_listing:Range, the intended effect of the reuse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve.

Clean review with no Medium-or-worse findings. The PR rewrites fix_signal_listing_range_subclasses in scripts/post_generate_fixes.py from brittle text-matching to an AST shape comparison against SignalListing.Range (fail-closed raise on divergence at :185-191; local class replaced with an assignment at :200 so consolidate_exports still re-exports a declared Range).

No public-API break: get_signals_response.Range becomes the same object as signal_listing.Range, so aliases.py's Range as SignalCoverageRange import still resolves. The snapshot move is __module__-only — same shape (extra='forbid', StrictFloat), no wire change, so no breaking semver signal or changeset is required. Manifest flip to a string reason is consistent and self-graded by _check_manifest. New test file tests/test_signal_listing_range_reuse.py (272 lines) covers divergence, idempotency, parenthesized-import, and the integration identity assertion.

Decision table: no critical/high/medium findings; gated_paths false; high_risk false; no author team gate; no prior decision. Falls through to row 9 → approve.

bokelley and others added 2 commits November 9, 2026 22:32
…s SignalListing

The earlier commit aliased Range by assignment in two named files. That broke
the domains invariant that a module binds only classes it defines
(test_no_generated_type_is_reachable_under_zero_names). It also changed what
signals/get_signals_response.Range is, though that module stopped subclassing
SignalListing when 3.3 inlined its signal, so there is no parent type to match.

The pass now scans every generated module for a SignalListing subclass that
declares Range, deletes that copy when it validates like SignalListing.Range,
and adds Range to the module's existing signal_listing import. It raises on a
divergent copy, on an import it cannot extend, and on a surviving duplicate.
get_signals_response keeps its own Range, so the public API snapshot and
adcp.types.domains.signals.Range are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bokelley bokelley changed the title fix(codegen): reuse SignalListing.Range by shape, and fail loudly when a copy survives fix(codegen): reuse SignalListing.Range in SignalListing subclasses by shape, and fail loudly on a miss Nov 10, 2026

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — clean pass, no blocking or medium findings.

Checked: the delta refines fix_signal_listing_range_subclasses (post_generate_fixes.py:5231-5240) to reuse SignalListing.Range only where a module actually subclasses SignalListing, leaving inlined get_signals_response with its own Range. Logic is fail-closed on divergence, attribute-base-without-direct-import, and surviving duplicates, covered by 19 tests. The add_to_import module reconstruction (5279) matches the import pattern. The SignalCoverageRange/Range snapshot repoint is shape-identical (min/max StrictFloat, closed) with aliases.py unchanged — a correction, not a semver break, so no breaking-change commit prefix is required.

One non-blocking note (Low, omitted from blockers): the PR body's "public API snapshot is unchanged" claim is stale since the snapshot repointed two entries, but the underlying change is sound.

Decision path: no critical/high/medium findings; high_risk false; gated_paths false (and review_decision is APPROVED regardless); no no-auto-approve team match. Rows 1–8 do not fire → row 9 approve. Prior decision was also approve; this is a net-neutral/clean continuation.

@bokelley
bokelley merged commit c8434e2 into next Nov 10, 2026
41 checks passed
@bokelley
bokelley deleted the fix/codegen-range-dedup-open-schema branch November 10, 2026 12:21

@KonstantinMirin KonstantinMirin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi Brian, thanks for this. I looked at b44fe3287f. Your later commit drops the Range = _SignalListingRange alias, which also clears the export-surface test failure I saw at 7b8520eab.

The main thing: I'd rather fix this in the schema and delete the pass than make it sturdier. signal-listing.json#/properties/range, get-signals-response.json#/properties/signals/items/properties/range and wholesale-feed-event.json#/$defs/signal/properties/range are three copies of the same object (min, max, both required), differing only in description. They sit inside allOf children that already reference signal-listing, and that duplication is the only reason three Range classes come out. If the spec defines it once, as core/signal-range.json referenced from signal-listing, with the two copies removed, it validates the same, so it's fine in a minor. I've suggested it on #8866, which already edits all three objects. After that, fix_signal_listing_range_subclasses, its helpers, the manifest entry and tests/test_signal_listing_range_reuse.py can go, and aliases.py:178 points at the generated SignalRange.

As it stands at the new head, the tree also keeps two distinct types for one wire object: get_signals_response.Range is not signal_listing.Range, and tests/test_signal_listing_range_reuse.py:295 asserts that as intended.

A smaller point, only if the pass stays: "fail loudly when a copy survives" is narrower than described. scripts/post_generate_fixes.py:5238-5242 skips a module whose copy the generator renamed (for example Range1), and the survival check at 5281-5282 runs right after the only class Range was replaced, so it can't fire. The guards that work are the divergence raise and the real-tree test.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants